require 'rint_core/g_code/codes'
require 'rint_core/g_code/line'
require 'rint_core/pretty_output'

module RintCore
  module GCode
    # A class that represents a processed GCode file.
    class Object
      include RintCore::GCode::Codes
      include RintCore::PrettyOutput

      # An array of the raw Gcode with each line as an element.
      # @return [Array] of raw GCode without the comments stripped out.
      attr_accessor :raw_data
      # @!macro attr_reader
      #   @!attribute [r] $1
      #     @return [Array<Line>] an array of {Line}s.
      #   @!attribute [r] $2
      #     @return [Float] the smallest X coordinate of an extrusion line.
      #   @!attribute [r] $3
      #     @return [Float] the biggest X coordinate of an extrusion line.
      #   @!attribute [r] $4
      #     @return [Float] the smallest Y coordinate of an extrusion line.
      #   @!attribute [r] $5
      #     @return [Float] the biggest Y coordinate of an extrusion line.
      #   @!attribute [r] $6
      #     @return [Float] the smallest Z coordinate.
      #   @!attribute [r] $7
      #     @return [Float] the biggest Z coordinate.
      #   @!attribute [r] $8
      #     @return [Array<Float>] the amount in mm of fliament extruded with the index representing the extruder.
      #   @!attribute [r] $9
      #     @return [Float] the distance in total that the X axis will travel in mm.
      #   @!attribute [r] $10
      #     @return [Float] the distance in total that the Y axis will travel in mm.
      #   @!attribute [r] $11
      #     @return [Float] the distance in total that the Z axis will travel in mm.
      #   @!attribute [r] $12
      #     @return [Float] the distance in total that the E axis will travel in mm.
      #     @todo implement this
      #   @!attribute [r] $13
      #     @return [Float] the width of the print.
      #   @!attribute [r] $14
      #     @return [Float] the depth of the print.
      #   @!attribute [r] $15
      #     @return [Float] the height of the print.
      #   @!attribute [r] $16
      #     @return [Fixnum] the number of layers in the print.
      #   @!attribute [r] $17
      #   @return [Float] the estimated durration of the print in seconds.
      #   @!attribute [r] $18
      #   @return [Array] of the lines that only contained comments found in the file.
      #   @!attribute [r] $19
      #   @return [Array<Hash<Fixnum>>] ranges of commands with their respective layer represented by the array index.
      attr_reader :lines, :x_min, :x_max, :y_min, :y_max, :z_min, :z_max,
                  :filament_used, :x_travel, :y_travel, :z_travel, :e_travel,
                  :width, :depth, :height, :layers, :total_duration, :comments,
                  :layer_ranges

      # Creates a GCode {Object}.
      # @param data [String] path to a GCode file on the system.
      # @param data [Array] with each element being a line of GCode.
      # @param auto_process [Boolean] enable/disable auto processing.
      # @param default_speed [Float] the default speed (in mm/minute) for moves that don't have one declared.
      # @param acceleration [Float] the acceleration rate set in the printer' firmware.
      # @return [Object] if data is valid, returns a GCode {Object}.
      # @return [false] if data is not an array, path, didn't contain GCode or default_speed wasn't a number grater than 0.
      def initialize(data = nil, default_speed = 2400, auto_process = true, acceleration = 1500)
        return false if positive_number?(default_speed)
        return false if positive_number?(acceleration)
        if data.class == String && self.class.is_file?(data)
          data = self.class.get_file(data)
        end
        return false if data.nil? || data.class != Array
        set_variables(data, default_speed, acceleration)
        @raw_data.each do |line|
          line = set_line_properties(RintCore::GCode::Line.new(line))
          if line
            unless line.empty?
              @lines << line
            else
              @comments << line.comment
            end
          end
        end
        process if auto_process
        return false if empty?
      end

      # Checks if the given string is a file and if it exists.
      # @param file [String] path to a file on the system.
      # @return [Boolean] true if is a file that exists on the system, false otherwise.
      def self.is_file?(file)
        !file.nil? && !file.empty? && File.exist?(file) && File.file?(file)
      end

      # Returns an array of the lines of the file if it exists.
      # @param file [String] path to a file on the system.
      # @return [Array] containting the lines of the given file as elements.
      # @return [false] if given string isn't a file or doesn't exist.
      def self.get_file(file)
        return false unless self.is_file?(file)
        IO.readlines(file)
      end

      # Get the given line of the object.
      # @return [Line] the given line
      def [](index)
        @lines[index]
      end

      # alias for {#empty?}.
      # @see #empty?
      def blank?
        empty?
      end

      # Checks if there are any {Line}s in {#lines}.
      # @return [Boolean] true if no lines, false otherwise.
      def empty?
        @lines.empty?
      end

      # Returns the number of lines in the object.
      # @return [Fixnum] the number of lines in the object.
      def length
        @lines.length
      end

      # Opposite of {#empty?}.
      # @see #empty?
      def present?
        !empty?
      end

      # Checks if the GCode object contains multiple materials.
      # @return [nil] if processing hasn't been done.
      # @return [Boolean] true if multiple extruders used, false otherwise.
      def multi_material?
        return nil unless @width
        @filament_used.length > 1
      end

      # Returns estimated durration of the print in a human readable format.
      # @return [String] human readable estimated durration of the print.
      def durration_in_words
        seconds_to_words(@total_duration)
      end

      # Get the layer number the given command number is in.
      # @param command_number [Fixnum] number of the command who's layer number you'd lke to know.
      # @return [Fixnum] layer number for the given command number.
      # @return [nil] if the given command number is invalid or if the object wasn't processed.
      def in_what_layer?(command_number)
        return nil if @width.nil? || !command_number.is_a?(Fixnum) || command_number < 0 || command_number > @lines.length
        layer = 1
        @layers.times do
          return layer if (@layer_ranges[layer][:lower]..@layer_ranges[layer][:upper]).include?(command_number)
          layer += 1
        end
        nil
      end

private

      def process
        set_processing_variables

        @lines.each do |line|
          case line.command
          when USE_INCHES
            @imperial = true
          when USE_MILLIMETRES
            @imperial = false
          when ABS_POSITIONING
            @relative = false
          when REL_POSITIONING
            @relative = true
          when SET_POSITION
            set_positions(line)
          when HOME
            home_axes(line)
          when RAPID_MOVE
            count_layers(line)
            movement_line(line)
          when CONTROLLED_MOVE
            count_layers(line)
            movement_line(line)
            calculate_time(line)
          when DWELL
            @total_duration += line.p/1000 unless line.p.nil?
          end
          @current_line += 1
        end
        @layer_ranges[@layers][:upper] = @current_line
        set_dimensions
      end

      def set_dimensions
        @width = @x_max - @x_min
        @depth = @y_max - @y_min
        @height = @z_max - @z_min
      end

      def calculate_time(line)
        @speed_per_second = line.f / 60
        current_travel = hypot3d(@current_x, @current_y, @current_z, @last_x, @last_y, @last_z)
        distance = (2*((@last_speed_per_second+@speed_per_second)*(@speed_per_second-@last_speed_per_second)*0.5)/@acceleration).abs
        if distance <= current_travel && !(@last_speed_per_second+@speed_per_second).zero? && !@speed_per_second.zero?
          move_duration = (2*distance/(@last_speed_per_second+@speed_per_second))+((current_travel-distance)/@speed_per_second)
        else
          move_duration = Math.sqrt(2*distance/@acceleration)
        end
        @total_duration += move_duration
      end

      def count_layers(line)
        if !line.z.nil? && line.z > @current_z
          @layer_ranges[@layers][:upper] = @current_line
          @layers += 1
          @layer_ranges[@layers] = {}
          @layer_ranges[@layers][:lower] = @current_line
        end
      end

      def hypot3d(x1, y1, z1, x2 = 0.0, y2 = 0.0, z2 = 0.0)
        return Math.hypot(x2-x1, Math.hypot(y2-y1, z2-z1))
      end

      def movement_line(line)
        measure_travel(line)
        set_last_values
        set_current_position(line)
        set_limits(line)
      end

      def measure_travel(line)
        if @relative
          @x_travel += to_mm(line.x).abs unless line.x.nil?
          @y_travel += to_mm(line.y).abs unless line.y.nil?
          @z_travel += to_mm(line.z).abs unless line.z.nil?
        else
          @x_travel += (@current_x - to_mm(line.x)).abs unless line.x.nil?
          @y_travel += (@current_y - to_mm(line.y)).abs unless line.y.nil?
          @z_travel += (@current_z - to_mm(line.z)).abs unless line.z.nil?
        end
      end

      def home_axes(line)
        if !line.x.nil? || line.full_home?
          @x_travel += @current_x
          @current_x = 0
        end
        if !line.y.nil? || line.full_home?
          @y_travel += @current_y
          @current_y = 0
        end
        if !line.z.nil? || line.full_home?
          @z_travel += @current_z
          @current_z = 0
        end
      end

      def positive_number?(number, grater_than = 0)
        number.nil? && (number.is_a?(Fixnum) || number.is_a?(Float)) && number >= grater_than
      end

      def set_last_values
        @last_x = @current_x
        @last_y = @current_y
        @last_z = @current_z
        @last_speed_per_second = @speed_per_second
      end

      def set_positions(line)
        @current_x = to_mm(line.x) unless line.x.nil?
        @current_y = to_mm(line.y) unless line.y.nil?
        @current_z = to_mm(line.z) unless line.z.nil?
        unless line.e.nil?
          @filament_used[line.tool_number] = 0 if @filament_used[line.tool_number].nil?
          @filament_used[line.tool_number] += @current_e
          @current_e = to_mm(line.e)
        end
      end

      def set_current_position(line)
        if @relative
          @current_x += to_mm(line.x) unless line.x.nil?
          @current_y += to_mm(line.y) unless line.y.nil?
          @current_z += to_mm(line.z) unless line.z.nil?
          @current_e += to_mm(line.e) unless line.e.nil?
        else
          @current_x = to_mm(line.x) unless line.x.nil?
          @current_y = to_mm(line.y) unless to_mm(line.y).nil?
          @current_z = to_mm(line.z) unless line.z.nil?
          @current_e = to_mm(line.e) unless line.e.nil?
        end
      end

      def set_limits(line)
        if line.extrusion_move?
          unless line.x.nil?
            @x_min = @current_x if @current_x < @x_min
            @x_max = @current_x if @current_x > @x_max
          end
          unless line.y.nil?
            @y_min = @current_y if @current_y < @y_min
            @y_max = @current_y if @current_y > @y_max
          end
        end
        unless line.z.nil?
          @z_min = @current_z if @current_z < @z_min
          @z_max = @current_z if @current_z > @z_max
        end
      end

      def set_line_properties(line)
        return false unless line
        return line if line.command.nil?
        @tool_number = line.tool_number unless line.tool_number.nil?
        line.tool_number = @tool_number if line.tool_number.nil?
        @speed = line.f unless line.f.nil?
        line.f = @speed if line.f.nil?
        line
      end

      def set_processing_variables
        @x_travel = 0
        @y_travel = 0
        @z_travel = 0
        @current_x = 0
        @current_y = 0
        @current_z = 0
        @current_e = 0
        @last_x = 0
        @last_y = 0
        @last_z = 0
        @last_e = 0
        @x_min = 999999999
        @y_min = 999999999
        @z_min = 0
        @x_max = -999999999
        @y_max = -999999999
        @z_max = -999999999
        @filament_used = []
        @layers = 1
        @layer_ranges = []
        @layer_ranges[1] = {}
        @layer_ranges[1][:lower] = 0
        @current_line = 0
        # Time
        @speed_per_second = 0.0
        @last_speed_per_second = 0.0
        @move_duration = 0.0
        @total_duration = 0.0
        @acceleration = 1500.0 #mm/s/s  ASSUMING THE DEFAULT FROM SPRINTER !!!!
      end

      def set_variables(data, default_speed, acceleration)
        @raw_data = data
        @imperial = false
        @relative = false
        @tool_number = 0
        @speed = default_speed.to_f
        @acceleration = acceleration
        @lines = []
        @comments = []
      end

      def to_mm(number)
        return number unless @imperial
        number *= 25.4 if !number.nil?
      end

    end
  end
end